Skip to content

Skia parity: premultiplied rendering, layer effects, and WebGPU interop rework - #410

Merged
JimBobSquarePants merged 57 commits into
mainfrom
js/skia-parity
Jul 31, 2026
Merged

Skia parity: premultiplied rendering, layer effects, and WebGPU interop rework#410
JimBobSquarePants merged 57 commits into
mainfrom
js/skia-parity

Conversation

@JimBobSquarePants

Copy link
Copy Markdown
Member

Prerequisites

  • I have written a descriptive pull-request title
  • I have verified that there are no overlapping pull-requests open
  • I have verified that I am following matches the existing coding patterns and practice as demonstrated in the repository. These follow strict Stylecop rules 👮.
  • I have provided test coverage for my change (where applicable)

Description

This PR takes the drawing pipeline to output parity with Skia across the CPU and WebGPU
backends. It lands associated-alpha (premultiplied) rendering end to end, a Skia-style
layer effect system, a reworked clipping model, substantial text rendering improvements,
a rebuilt native WebGPU interop layer, and a long list of rendering fixes, with
equivalence tests and golden comparisons holding both backends to matching output.

56 commits; ~330 source files (+53k/−7k) and ~6k lines of new test coverage.

Pipeline and backends

  • The CPU rasterizer gains per-fill aliased coverage, coverage-aware blending over
    normalized pre-transformed brushes, pooled worker state across parallel flushes, and
    rectangular clip fast paths, with the pipeline documentation expanded to match.

WebGPU backend

  • The native interop layer is rebuilt on in-repo generated wgpu bindings (headers,
    generator scripts, and pinned native loaders included), with a WGSL shader pipeline and
    a scene encoder with monoid-based draw ordering.
  • GPU-side shader effects (IWebGPUShaderEffect), external surface and texture interop
    (GpuImageSource, surface alpha modes, shared WebGPU instance across surfaces, macOS
    Metal layer factory), and premultiplied interop with native surfaces.
  • More of the WebGPU surface is public for external hosting scenarios, deferred readback
    buffers recycle correctly, and device recovery evicts cached pipeline and device state.
  • Robustness and throughput work: submission-index-scoped readbacks, deferred readbacks,
    command chunking with status handling, pipeline warmup, and tile-boundary winding fixes.
  • Native asset resolution works across every deployment model: flat outputs, the NuGet
    runtimes/ layout, self-contained publishes, and Native AOT — including AOT shared
    libraries, where probing anchors to the library's own image. The DirectX Shader Compiler
    path is resolved through the host's native-library search rather than directory
    conventions, and window platform registration is explicit so trimming cannot remove it.

Clipping

  • Clip state is a balanced save/restore stream with ClipOperation support (intersection
    and difference), region-path clipping, rectangular fast paths, and hard versus
    antialiased clip edges. Clips are pre-transformed and normalized once, then shared by
    both backends.

Layers and effects

  • SaveLayer gains effect-carrying overloads: bounds or a path plus a restore-time
    LayerEffect — blur, drop shadow, glow, inner shadow, and color matrix.
  • The save-time BackdropLayerEffect family covers the CSS backdrop-filter set (blur,
    brightness, contrast, color matrix, drop shadow, grayscale, hue rotate, invert, opacity,
    saturate, sepia) plus acrylic.
  • Layer target bounds expand by each effect's sampling reach, so intermediate surfaces
    allocate exactly what the effect reads.

Associated (premultiplied) alpha

  • Rendering into associated pixel formats is supported end to end, with equivalence tests
    asserting that straight and associated canvases composite to identical results across
    fills, gradients, images, text, clips, layers, and effects.
  • Gradients interpolate in associated space per CSS Color 4, with representation-aware
    pixel encoders shared across gradient brushes.

Brushes and images

  • Brushes are normalized and pre-transformed at preparation time; image brushes gain wrap
    modes (None, Repeat, Mirror, Clamp).
  • DrawImage converts only the clipped source region for foreign pixel formats and clips
    once at the public entry points; the core assumes pre-clipped input, keeping rectangle
    mapping bit-identical across overloads.

Text

  • A shared DrawingTextCache serves all canvases; glyph runs are culled and batched by
    subpixel phase.
  • Skip-ink decoration carving, continuous decoration bands for path text, a TextContrast
    coverage boost, and a reworked glyph drawing API (RichGlyphOptions) with corrected
    decoration transforms across layered and path text.

Geometry and correctness fixes

Performance

FillParis (the 30k-path Paris street map benchmark from the 3.0 announcement, 1096×1060,
fill-only with per-path transforms and opacity), re-measured on this branch. Environment:
AMD Ryzen AI Max+ 395 with Radeon 8060S, Windows 11, .NET 10.0, BenchmarkDotNet 0.15.8,
Server GC, 3 launches × 40 iterations, SkiaSharp as baseline.

Method Mean Ratio
SkiaSharp 103.4 ms 1.00
System.Drawing 147.3 ms 1.42
ImageSharp CPU 43.0 ms¹ 0.42¹
ImageSharp CPU, retained scene 11.9 ms 0.11
ImageSharp WebGPU 20.0 ms 0.19
ImageSharp WebGPU, retained scene 5.6 ms 0.05

¹ Median; one launch of three degraded (mean 57.2 ms, StdDev 22.7 ms), ratio computed
from the median.

Against the published 3.0 numbers on identical hardware, this branch improves every
ImageSharp result: immediate CPU 48.5 → 43.0 ms, retained CPU 17.9 → 11.9 ms, immediate
WebGPU 24.8 → 20.0 ms, with retained WebGPU holding at ~5.5 ms — 2.4x faster than
SkiaSharp on the CPU, 18x faster on the GPU with a retained scene.

Breaking changes

  • ShapeOptions is removed from the drawing APIs; IntersectionRule lives on
    DrawingOptions.
  • The clipping API is reshaped around the save/restore stream and ClipOperation.

Testing

  • ~6,000 lines of new and updated tests: associated-alpha equivalence suites, clip, layer,
    and effect coverage, DrawImage no-op and disposal cases, and WebGPU golden comparisons.
    Text rendering reference images are refreshed to match the corrected output.

Introduces a new clipping model that separates clip operations from state saving. Adds DrawingClipState, DrawingClipDescriptor, DrawingClipKind, and DrawingClipEdgeMode to manage clip primitives. Updates Save()/Restore() to work with explicit Clip() calls instead of clip parameters. Extends WebGPU shader pipeline to support difference clipping alongside intersection clips, with proper stack handling in clip_leaf and clip_reduce shaders. Updates samples and tests to use the new API pattern.
Simplifies drawing and clipping configuration by deleting `ShapeOptions` and moving fill-rule handling to `DrawingOptions.IntersectionRule`. Clipping APIs now take `BooleanOperation` and optional `IntersectionRule` directly, and rendering paths/backends were updated to use the new model. Tests were adjusted to cover the new option flow and preserve existing clipping/text behavior.
Correct elliptic gradient rotation and add isolated clip-group handling in WebGPU so layers and clip masks compose consistently with the CPU backend. Also updates related documentation and adds a regression test for rotated elliptic gradients.
Presentation flushes now defer scratch-overflow readbacks and resolve them on later flushes, with corrective rerenders for offscreen targets and frame pacing via queue work-done signals. Text rendering also switches to position-independent glyph caching with explicit sub-pixel offsets, and projective transforms now bypass the cache.
Adds a per-pixel-type static pool for `WorkerState<TPixel>` in the default CPU drawing backend. Worker states are now rented at the start of each `Parallel.For` loop and returned instead of disposed, reusing allocator-owned buffers (brush workspace, raster scratch, clip coverage) across flushes.

The pool is capped at `Environment.ProcessorCount` entries. A `Gen2TrimSentinel` finalizer trims the pool on each Gen2 GC cycle when no rent has occurred since the previous one, returning all held buffers to the memory allocator during idle periods.

Includes a new test that verifies all allocator buffers are returned after rendering stops and two Gen2 collections are observed. Reference output images updated to reflect rendering improvements in the same changeset.
Reworked `BaseGlyphBuilder` decoration tracking to dedupe repeated lines from stacked color glyph layers while still stitching only between adjacent glyphs (not within skip-ink segments). This fixes underline behavior with tracking (issue #405), keeps skip-ink behavior correct, and updates expected rendering outputs.

Also migrated text rendering calls to the newer `TextRenderer.Render(...)` overloads, isolated the worker-state pool idle-trim test in `RemoteExecutor` for deterministic GC behavior, and replaced a WebGPU pending-status record with an explicit readonly struct.
Introduce `DrawingOptions.TextContrast` (default `0.5`) and thread it through text command creation into `RasterizerOptions.CoverageBoost`. Apply the same S-curve coverage remap in both CPU rasterization (`AreaToCoverage`) and WebGPU fine shading for antialiased text only, while keeping fills/strokes/clips unchanged and preserving aliased-threshold behavior. Update shader comments and rasterizer/canvas docs to explain the shared coverage parameter and the curve rationale.
The rasterizer's recursive segment subdivider computed midpoints using 32-bit integer arithmetic, causing overflow for coordinates beyond ~4.2M pixels. The overflow placed the midpoint outside [x0, x1], so the segment never shrank and the recursion overflowed the stack.

Fix by widening dx/dy comparisons and midpoint arithmetic to 64-bit, then casting the result back to int. Adds regression tests covering extreme float values (large positive/negative, NaN, Infinity) and updates reference images affected by the rendering change.
Update the image baselines for text, path, emoji, and font rendering tests to match the current output.
Track and propagate CPU-side tile-crossing/bin-footprint estimates through scene encoding and range metadata, then use them to seed scratch buffer capacities with device-limit clamping. Preserve backend bump sizes as a high-water mark (max-merge) instead of shrinking per scene, and start best-effort background compute pipeline warmup at device creation to reduce first-flush shader compilation stalls.
Improves WebGPU staged-scene reliability by requesting adapter storage limits at device creation and by computing the effective scratch ceiling from both max storage binding size and max buffer size. Adds full deferred chunked overflow readback support, including variable-sized readback buffer pooling, chunk-aware pending status resolution, and shared chunk status aggregation logic. Tightens scene scratch seeding with per-segment tile-crossing estimates (fills and strokes) to avoid over/under-seeding, and adds diagnostics regression tests for black-screen, chunking behavior, and first-pass convergence.
Document why non-zero polygons must be normalized before clipping with the even-odd clipper, and remove an unused using from `ClippedShapeGenerator`.
Makes several WebGPU and drawing backend types/methods public for external use, and refreshes the related XML docs. Also fixes deferred readback buffer recycling and evicts cached device state during device recovery to avoid leaking pipelines and native device references.
Move from creating separate WebGPU instances per surface to a process-shared instance managed by the runtime. This fixes corruption of wgpu-native's global backend registry that occurs during rapid window reopens, which would leave freshly created surfaces invalid and cause process aborts. Surfaces now borrow the shared instance via non-owning handles.
Replaces the previous stitch-and-suppress decoration approach with a two-glyph sliding window that carves skip-ink gaps around actual ink intervals (widened by the drawn thickness) and emits the surviving segments. Key changes:

- `BaseGlyphBuilder` now receives ink intersection intervals from the font engine and uses a `DecorationLane`/`PendingDecoration` window to carve each glyph against its neighbours.
- `RichTextRun.GetDecorationOptions` reports the pen's stroke width as the decoration thickness so skip-ink gaps are sized to match what is actually painted.
- `RichTextGlyphRenderer.SetDecoration` simplified: path already carries the correct dimensions; pen rescaling and anchor logic removed.
- `DrawTextOperations` / `CreateTextCompositionCommand` drop the redundant `clipState` parameter.
- `DefaultTextContrast` raised from 0.1 to 0.5.
- New test `DrawTextUnderlinePenSkipsInk` verifies that a thick pen underline is still broken by descender ink.
Introduces a new `LayerEffect`/`BackdropLayerEffect` model and new `DrawingCanvas.SaveLayer` overloads to apply blur, shadow, glow, inner-shadow, color-matrix, and CSS-style backdrop filters over rectangle or path regions. `Apply` commands now support explicit write-back compositing options and offsets, with backend scene/apply plumbing updated so reads come from the pre-offset region while writes land at the offset target. WebGPU composition was also corrected to store clip stack blend snapshots in straight alpha before packing, improving low-alpha color fidelity and matching CPU round-trips. Adds extensive WebGPU parity tests, a new balloon input image, and updated/generated reference outputs.
Fix CreateApplyRasterizerOptions to properly handle transformed path bounds. When a transform is applied to the rasterization options, the interest rectangle must be calculated from the transformed bounds, not the untransformed bounds. This ensures coverage is correctly cropped for transformed paths.
Replace the Silk.NET WebGPU bindings with checked-in wgpu-native bindings and packaged RID-specific native libraries. Add shared external-surface sessions and persistent presentation targets with direct-copy and render-pipeline fallback paths across supported native window hosts.

Carry alpha representation and numeric encoding through canvas creation, shaders, copies, readback, and presentation. Implement CSS Color 4 premultiplied interpolation for CPU and WebGPU gradients, including path gradients and half-float targets.

Expand callback lifetime, surface, format, gradient, and CPU/WebGPU parity coverage, and update the verified rendering references.
Introduces a full WebGPU shader-effect pipeline (custom WGSL programs, uniforms/layouts, pass sequencing, diagnostics, compilation errors, precompile support, and CPU fallbacks) plus new built-in WebGPU layer/backdrop effects (blur, color matrix, drop shadow, glow, inner shadow, acrylic). Refactors backend/runtime internals to explicit WGPU native types, adds native library resolution, error-scope callbacks, effect pipeline caching, pooled textures/buffers, and ordered-scene shader-effect execution support. Updates WebGPU samples to a shared surface session architecture with scene-owned views, adds a new custom shader demo scene, refreshes window demo rich text rendering, and expands tests/reference outputs including shader source validation coverage.
Refactors text-along-path decoration rendering to accumulate contiguous same-styled glyph cells into a single segment rather than emitting one rectangle per glyph. Path-following decorations are now emitted as sampled curve bands instead of per-glyph rotated rectangles, eliminating double anti-aliased edges at cell boundaries.

- Moves path-transform logic from `RichTextGlyphRenderer`/`PathGlyphBuilder` into `BaseGlyphBuilder` via a new `TextPath` property and `ApplyPathTransform` method
- Deletes `PathGlyphBuilder` (replaced by a new `GlyphBuilder(IPath)` constructor)
- Adds `PendingSegment` accumulator to `DecorationLane` with contiguity merging
- Adds `EmitPathDecorationBand` for curve-sampled decoration geometry
- Extracts `RegisterDecorationPath` to share path registration between straight and curved emitters
- Adds `DrawTextDecorated` benchmark and `RichTextGlyphRendererTests` unit tests
- Updates reference image for path-text triangle test
Adds translation-only matrix detection and uses it to derive visible text bounds from canvas state, enabling safe line culling for text while correctly disabling the fast path for non-translation transforms. Text composition commands now carry a dedicated sub-pixel offset (instead of embedding it into per-glyph drawing options transforms), and backend prep paths apply that offset consistently. The batcher also avoids rebuilding scene commands when brush transforms are effectively no-ops. New text tests cover offscreen culling, translated and rotated transforms, and text-block rendering parity against unculled output.
Introduce a `Reach` contract on `LayerEffect` and implement effect-specific reach values (blur, glow, shadows, color matrix, backdrop) so layer processing can reserve enough pixels for blur spread and offsets. `DrawingCanvas.SaveLayer` now inflates content/region bounds by effect reach to prevent clipped output, and a new SaveLayer blur test covers the box-shadow-style case where output must extend and feather beyond the original content bounds.
# Conflicts:
#	src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs
Reworked text rendering entry points to use positioned glyph spans (`glyphIds` + `points`) instead of `GlyphRun` in `DrawingCanvas` and `TextBuilder`, and updated call sites/tests accordingly. Image drawing was also tightened by moving clipping responsibility to callers so `DrawImageCore` now assumes pre-clipped input, reducing duplicate clipping paths and keeping rect mapping consistent. The changes also include small WebGPU/sample cleanup and analyzer-driven modernizations (`_ =` discard assignments, target-typed collection literals, simplified lambdas), plus package version bumps and refreshed image baselines impacted by text output changes.
Resolve the DirectX Shader Compiler through the host's native-library
search and recover its location from the loaded module, replacing the
application-directory probe that failed under the NuGet runtimes layout.
Anchor fallback probing to the library's own image so Native AOT shared
libraries hosted by foreign processes resolve wgpu_native and DXC, and
register the Silk.NET window platforms explicitly so trimming cannot
remove platform discovery.
@JimBobSquarePants JimBobSquarePants added bug Something isn't working enhancement New feature or request API breaking labels Jul 30, 2026
The compute-pipeline probe passed on adapters whose submission or render
path later fails natively, letting GPU-less CI hosts crash the test
process inside the queue submit. Extend the isolated probe to prove an
indexed submission with buffer readback and a layered retained-scene
render, and add a ForceFallbackAdapter environment option so software
adapters such as WARP can be selected deliberately for diagnosis.
Split the isolated availability probe into staged checks: pipeline
creation, an indexed submission with buffer readback, one production
compute dispatch, and a layered retained-scene render. Distinct exit
codes name the first failing stage, and codes reserved for completed
probes map to the unsupported result instead of a process failure.
Adapters whose driver stack wedges after real render work, such as
WARP, now classify as unsupported before any test or consumer can
reach the native fault.
Rerun the WebGPU suite on the Windows job with the support probe
bypassed and minidump collection enabled, uploading any crash dump as
an artifact. The step never affects job status and exists to capture
the WARP-only native fault with symbols for upstream diagnosis.
An exception during an ordered flush skipped copying the executor's replacement
arenas back to the caller, so the finally returned already-disposed arenas to
the reuse caches; a later drain then released their buffers a second time,
corrupting native memory. Arenas now copy back unconditionally, and both arena
types dispose behind an interlocked flag, nulling each buffer so a stale
reference fails CanReuse instead of touching freed memory.

Disposal is hardened across both projects: guard flags set before releases,
exception-safe multi-buffer creation, per-entry isolation in the deferred
status harvest, retained-scene item release on the empty and exception paths
of scene creation, root/region canvases sharing one deferred-image list, and
decoration ink buffers owned by the glyph builder base with a chained virtual
dispose.

Software adapters are rejected at adapter selection via wgpuAdapterGetInfo;
GPU-less environments are served by the CPU backend. The staged submission,
dispatch, and scene-render probes and the ForceFallbackAdapter option are
removed along with the CI diagnostic capture.
WGSL leaves atan2 implementation-defined when x is zero, and axis-aligned caps
and joins produce exactly those inputs; an adapter returning an indeterminate
value there collapsed the sweep and dropped entire round-cap arcs. Cap and
join arcs now derive their sweep from atan2(cross, dot), which stays away from
the poles for every non-degenerate offset pair, and generate interior points
by rotating the start offset instead of evaluating absolute angles.
The reference images are rendered on one adapter; conforming adapters differ
by one LSB across a small fraction of pixels, and by antialiased edge coverage
on region boundaries. The measured differences are 0.003-0.010 percent under
the comparer's metric, against thresholds of 0.0005-0.0006; the new values
carry roughly double the measured headroom while staying far below the 0.39
percent a single-LSB difference on every pixel would produce.
Each threshold is the worst measured cross-adapter difference at its call
site, plus one at the measured precision: 0.0042 to 0.0043 for the backdrop
filters, 0.0044 to 0.0045 for the many-layer clip reduce, and 0.0097 to
0.0098 for the nested-region edge coverage.
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.62771% with 560 lines in your changes missing coverage. Please review.
✅ Project coverage is 79%. Comparing base (01607ee) to head (959d2df).

Files with missing lines Patch % Lines
.../ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.cs 63% 92 Missing and 22 partials ⚠️
...arp.Drawing.WebGPU/WebGPUDrawingBackend.Ordered.cs 82% 77 Missing and 32 partials ⚠️
...c/ImageSharp.Drawing.WebGPU/WebGPUCanvasFactory.cs 30% 69 Missing and 1 partial ⚠️
src/ImageSharp.Drawing.WebGPU/WebGPU.cs 79% 44 Missing ⚠️
...ImageSharp.Drawing.WebGPU/WebGPUExternalSurface.cs 0% 32 Missing ⚠️
...Sharp.Drawing.WebGPU/Native/NativeModuleLocator.cs 0% 29 Missing ⚠️
....Drawing.WebGPU/WebGPUDrawingBackend.CopyPixels.cs 72% 12 Missing and 12 partials ⚠️
....Drawing.WebGPU/WebGPUBackdropShaderLayerEffect.cs 45% 18 Missing and 1 partial ⚠️
src/ImageSharp.Drawing.WebGPU/WebGPUEnvironment.cs 0% 19 Missing ⚠️
...c/ImageSharp.Drawing.WebGPU/WebGPUDeviceContext.cs 15% 16 Missing ⚠️
... and 21 more
Additional details and impacted files
@@           Coverage Diff           @@
##           main    #410      +/-   ##
=======================================
- Coverage    86%     79%      -8%     
=======================================
  Files       109     231     +122     
  Lines      9066   26195   +17129     
  Branches   1090    2953    +1863     
=======================================
+ Hits       7838   20745   +12907     
- Misses      979    4567    +3588     
- Partials    249     883     +634     
Flag Coverage Δ
unittests 79% <76%> (-8%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Coverage previously excluded the entire WebGPU assembly via a temporary
runsettings filter; the assembly is now measured, with generated interop
bindings and embedded shader text excluded as non-executable.

New tests pin the Region rect-set contract (previously untested), the
DrawingClipState stack including inline-slot overflow, translation,
transformation and bounds queries, and completeness of the environment
error messages, which caught SoftwareAdapterUnsupported mapping to the
unknown-error fallback.
The shared-infrastructure guard sources are covered by the submodule's own
tests and are excluded from this repository's metric.

New tests cover the Brushes pattern factories in both overload forms, the
LinearGeometry polyline surface including segment enumeration, lengths,
shoelace areas, winding containment, distance queries and sub-path
extraction, path normalization including the positive-winding lobe
selection for self-intersecting contours, the native-to-public error type
mapping, the window option defaults, the shader diagnostic and compilation
exception surface, and the native type name attribute.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

API breaking bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Text Render With Underline And Tracking Not Rendered Properly StackOverflow in DefaultRasterizer.AddContainedLineF24Dot8 when invoking DrawLine

1 participant